Coverage for mcpgateway/utils/verify_credentials.py: 100%

1# -*- coding: utf-8 -*-

2"""

3

4 Copyright 2025

5 SPDX-License-Identifier: Apache-2.0

6 Authors: Mihai Criveti

7

8"""

9

10# Standard

11 from typing import Optional

12

13# Third-Party

14 from fastapi import Cookie, Depends, HTTPException, status

15 from fastapi.security import (

16 HTTPAuthorizationCredentials,

17 HTTPBasic,

18 HTTPBasicCredentials,

19 HTTPBearer,

20)

21 from fastapi.security.utils import get_authorization_scheme_param

22 import jwt

23 from jwt import PyJWTError

24

25# First-Party

26 from mcpgateway.config import settings

27

28 basic_security = HTTPBasic(auto_error=False)

29 security = HTTPBearer(auto_error=False)

30

31

32 async def verify_jwt_token(token: str) -> dict:

33 """Verify and decode a JWT token.

34

35 Args:

36 token: The JWT token to verify.

37

38 Returns:

39 dict: The decoded token payload containing claims.

40

41 Raises:

42 HTTPException: If the token has expired or is invalid.

43 """

44 try:

45 # Decode and validate token

46 payload = jwt.decode(

47 token,

48 settings.jwt_secret_key,

49 algorithms=[settings.jwt_algorithm],

50 # options={"require": ["exp"]}, # Require expiration

51 )

52 return payload # Contains the claims (e.g., user info)

53 except jwt.ExpiredSignatureError:

54 raise HTTPException(

55 status_code=status.HTTP_401_UNAUTHORIZED,

56 detail="Token has expired",

57 headers={"WWW-Authenticate": "Bearer"},

58 )

59 except PyJWTError:

60 raise HTTPException(

61 status_code=status.HTTP_401_UNAUTHORIZED,

62 detail="Invalid token",

63 headers={"WWW-Authenticate": "Bearer"},

64 )

65

66

67 async def verify_credentials(token: str) -> dict:

68 """Verify credentials using a JWT token.

69

70 This function uses verify_jwt_token internally which may raise exceptions.

71

72 Args:

73 token: The JWT token to verify.

74

75 Returns:

76 dict: The validated token payload with the original token added.

77 """

78 payload = await verify_jwt_token(token)

79 payload["token"] = token

80 return payload

81

82

83 async def require_auth(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), jwt_token: Optional[str] = Cookie(None)) -> str | dict:

84 """Require authentication via JWT token.

85

86 Checks for a JWT token either in the Authorization header or as a cookie.

87

88 Args:

89 credentials: HTTP Authorization credentials from the request header.

90 jwt_token: JWT token from cookies.

91

92 Returns:

93 str or dict: The verified credentials payload or "anonymous" if authentication is not required.

94

95 Raises:

96 HTTPException: If authentication is required but no valid token is provided.

97 """

98 token = credentials.credentials if credentials else jwt_token

99

100 if settings.auth_required and not token:

101 raise HTTPException(

102 status_code=status.HTTP_401_UNAUTHORIZED,

103 detail="Not authenticated",

104 headers={"WWW-Authenticate": "Bearer"},

105 )

106 return await verify_credentials(token) if token else "anonymous"

107

108

109 async def verify_basic_credentials(credentials: HTTPBasicCredentials) -> str:

110 """Verify provided credentials.

111

112 Args:

113 credentials: HTTP Basic credentials.

114

115 Returns:

116 The username if credentials are valid.

117

118 Raises:

119 HTTPException: If credentials are invalid.

120 """

121 is_valid_user = credentials.username == settings.basic_auth_user

122 is_valid_pass = credentials.password == settings.basic_auth_password

123

124 if not (is_valid_user and is_valid_pass):

125 raise HTTPException(

126 status_code=status.HTTP_401_UNAUTHORIZED,

127 detail="Invalid credentials",

128 headers={"WWW-Authenticate": "Basic"},

129 )

130 return credentials.username

131

132

133 async def require_basic_auth(credentials: HTTPBasicCredentials = Depends(basic_security)) -> str:

134 """Require valid authentication.

135

136 Args:

137 credentials: HTTP Basic credentials provided by the client.

138

139 Returns:

140 str: The authenticated username or "anonymous" if auth is not required.

141

142 Raises:

143 HTTPException: If authentication is required but no valid credentials are provided.

144 """

145 if settings.auth_required:

146 if not credentials:

147 raise HTTPException(

148 status_code=status.HTTP_401_UNAUTHORIZED,

149 detail="Not authenticated",

150 headers={"WWW-Authenticate": "Basic"},

151 )

152 return await verify_basic_credentials(credentials)

153 return "anonymous"

154

155

156 async def require_auth_override(

157 auth_header: str | None = None,

158 jwt_token: str | None = None,

159) -> str | dict:

160 """

161 Call :func:`require_auth` manually from middleware, without FastAPI

162 dependency injection.

163

164 Args:

165 auth_header: Raw ``Authorization`` header value

166 (e.g. ``"Bearer eyJhbGciOi..."``).

167 jwt_token: JWT taken from a cookie. If both header and cookie are

168 supplied, the header wins.

169

170 Returns:

171 str or dict: Whatever :func:`require_auth` returns

172 (decoded JWT payload or the string ``"anonymous"``).

173

174 Note:

175 This wrapper may propagate :class:`fastapi.HTTPException` raised by

176 :func:`require_auth`, but it does not raise anything on its own, so

177 we omit a formal *Raises* section to satisfy pydocstyle.

178 """

179 credentials = None

180 if auth_header:

181 scheme, param = get_authorization_scheme_param(auth_header)

182 if scheme.lower() == "bearer" and param:

183 credentials = HTTPAuthorizationCredentials(scheme=scheme, credentials=param)

184

185 return await require_auth(credentials=credentials, jwt_token=jwt_token)