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

45 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-09 11:03 +0100

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

2""" 

3 

4Copyright 2025 

5SPDX-License-Identifier: Apache-2.0 

6Authors: Mihai Criveti 

7 

8""" 

9 

10# Standard 

11from typing import Optional 

12 

13# Third-Party 

14from fastapi import Cookie, Depends, HTTPException, status 

15from fastapi.security import ( 

16 HTTPAuthorizationCredentials, 

17 HTTPBasic, 

18 HTTPBasicCredentials, 

19 HTTPBearer, 

20) 

21from fastapi.security.utils import get_authorization_scheme_param 

22import jwt 

23from jwt import PyJWTError 

24 

25# First-Party 

26from mcpgateway.config import settings 

27 

28basic_security = HTTPBasic(auto_error=False) 

29security = HTTPBearer(auto_error=False) 

30 

31 

32async 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 

67async 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 

83async 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 

109async 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 

133async 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 

156async 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)