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

45 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-22 16:21 +0100

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

2""" 

3 

4Copyright 2025 

5SPDX-License-Identifier: Apache-2.0 

6Authors: Mihai Criveti 

7 

8""" 

9 

10from typing import Optional 

11 

12import jwt 

13from fastapi import Cookie, Depends, HTTPException, status 

14from fastapi.security import ( 

15 HTTPAuthorizationCredentials, 

16 HTTPBasic, 

17 HTTPBasicCredentials, 

18 HTTPBearer, 

19) 

20from fastapi.security.utils import get_authorization_scheme_param 

21from jwt import PyJWTError 

22 

23from mcpgateway.config import settings 

24 

25basic_security = HTTPBasic(auto_error=False) 

26security = HTTPBearer(auto_error=False) 

27 

28 

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

30 """Verify and decode a JWT token. 

31 

32 Args: 

33 token: The JWT token to verify. 

34 

35 Returns: 

36 dict: The decoded token payload containing claims. 

37 

38 Raises: 

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

40 """ 

41 try: 

42 # Decode and validate token 

43 payload = jwt.decode( 

44 token, 

45 settings.jwt_secret_key, 

46 algorithms=[settings.jwt_algorithm], 

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

48 ) 

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

50 except jwt.ExpiredSignatureError: 

51 raise HTTPException( 

52 status_code=status.HTTP_401_UNAUTHORIZED, 

53 detail="Token has expired", 

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

55 ) 

56 except PyJWTError: 

57 raise HTTPException( 

58 status_code=status.HTTP_401_UNAUTHORIZED, 

59 detail="Invalid token", 

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

61 ) 

62 

63 

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

65 """Verify credentials using a JWT token. 

66 

67 This function uses verify_jwt_token internally which may raise exceptions. 

68 

69 Args: 

70 token: The JWT token to verify. 

71 

72 Returns: 

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

74 """ 

75 payload = await verify_jwt_token(token) 

76 payload["token"] = token 

77 return payload 

78 

79 

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

81 """Require authentication via JWT token. 

82 

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

84 

85 Args: 

86 credentials: HTTP Authorization credentials from the request header. 

87 jwt_token: JWT token from cookies. 

88 

89 Returns: 

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

91 

92 Raises: 

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

94 """ 

95 token = credentials.credentials if credentials else jwt_token 

96 

97 if settings.auth_required and not token: 

98 raise HTTPException( 

99 status_code=status.HTTP_401_UNAUTHORIZED, 

100 detail="Not authenticated", 

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

102 ) 

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

104 

105 

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

107 """Verify provided credentials. 

108 

109 Args: 

110 credentials: HTTP Basic credentials. 

111 

112 Returns: 

113 The username if credentials are valid. 

114 

115 Raises: 

116 HTTPException: If credentials are invalid. 

117 """ 

118 is_valid_user = credentials.username == settings.basic_auth_user 

119 is_valid_pass = credentials.password == settings.basic_auth_password 

120 

121 if not (is_valid_user and is_valid_pass): 

122 raise HTTPException( 

123 status_code=status.HTTP_401_UNAUTHORIZED, 

124 detail="Invalid credentials", 

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

126 ) 

127 return credentials.username 

128 

129 

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

131 """Require valid authentication. 

132 

133 Args: 

134 credentials: HTTP Basic credentials provided by the client. 

135 

136 Returns: 

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

138 

139 Raises: 

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

141 """ 

142 if settings.auth_required: 

143 if not credentials: 143 ↛ 144line 143 didn't jump to line 144 because the condition on line 143 was never true

144 raise HTTPException( 

145 status_code=status.HTTP_401_UNAUTHORIZED, 

146 detail="Not authenticated", 

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

148 ) 

149 return await verify_basic_credentials(credentials) 

150 return "anonymous" 

151 

152 

153async def require_auth_override( 

154 auth_header: str | None = None, 

155 jwt_token: str | None = None, 

156) -> str | dict: 

157 """ 

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

159 dependency injection. 

160 

161 Args: 

162 auth_header: Raw ``Authorization`` header value 

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

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

165 supplied, the header wins. 

166 

167 Returns: 

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

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

170 

171 Note: 

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

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

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

175 """ 

176 credentials = None 

177 if auth_header: 

178 scheme, param = get_authorization_scheme_param(auth_header) 

179 if scheme.lower() == "bearer" and param: 179 ↛ 182line 179 didn't jump to line 182 because the condition on line 179 was always true

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

181 

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