Coverage for src/alprina_cli/api/middleware/auth.py: 19%

43 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2025-11-14 11:27 +0100

1""" 

2Authentication middleware for Alprina API. 

3Handles API key verification and user authentication. 

4""" 

5 

6from fastapi import Header, HTTPException, Depends 

7from typing import Optional, Dict, Any 

8from loguru import logger 

9 

10from ..services.neon_service import neon_service 

11 

12 

13async def verify_api_key(authorization: Optional[str] = Header(None)) -> Dict[str, Any]: 

14 """ 

15 Verify authentication from Authorization header. 

16 

17 Supports both: 

18 - JWT tokens (web/mobile users) 

19 - API keys (CLI users) 

20 

21 Args: 

22 authorization: Authorization header (Bearer token) 

23 

24 Returns: 

25 User dict if authenticated 

26 

27 Raises: 

28 HTTPException: If authentication fails 

29 """ 

30 if not authorization: 

31 raise HTTPException( 

32 status_code=401, 

33 detail={ 

34 "error": "missing_authorization", 

35 "message": "Authorization header is required", 

36 "hint": "Include header: Authorization: Bearer <token>" 

37 } 

38 ) 

39 

40 # Extract token from "Bearer <token>" 

41 parts = authorization.split() 

42 if len(parts) != 2 or parts[0].lower() != "bearer": 

43 raise HTTPException( 

44 status_code=401, 

45 detail={ 

46 "error": "invalid_authorization_format", 

47 "message": "Authorization header must be: Bearer <token>" 

48 } 

49 ) 

50 

51 token = parts[1] 

52 

53 # NOTE: For Stack Auth JWT tokens, we don't verify them here because: 

54 # 1. Stack Auth handles JWT verification on the frontend 

55 # 2. Users are synced to Neon DB via /v1/auth/sync-stack-user endpoint 

56 # 3. The frontend should use API keys (not JWT) for backend API calls 

57 # 

58 # Only API keys (starting with alprina_sk_) are supported for backend authentication 

59 

60 # Try API key (CLI users and web dashboard) 

61 if token.startswith("alprina_sk_"): 

62 user = await neon_service.verify_api_key(token) 

63 

64 if user: 

65 # Check rate limits 

66 rate_limit = await neon_service.check_rate_limit(user["id"]) 

67 

68 if not rate_limit["allowed"]: 

69 raise HTTPException( 

70 status_code=429, 

71 detail={ 

72 "error": "rate_limit_exceeded", 

73 "message": rate_limit.get("reason", "Rate limit exceeded"), 

74 "limit": rate_limit.get("limit"), 

75 "used": rate_limit.get("used"), 

76 "hint": "Upgrade to Pro for higher limits" 

77 } 

78 ) 

79 

80 user["auth_type"] = "api_key" 

81 logger.info(f"User authenticated via API key: {user['email']} (ID: {user['id']})") 

82 return user 

83 else: 

84 logger.warning(f"Invalid API key attempted: {token[:20]}...") 

85 raise HTTPException( 

86 status_code=401, 

87 detail={ 

88 "error": "invalid_api_key", 

89 "message": "API key is invalid or has been revoked", 

90 "hint": "Check your API key at /v1/auth/api-keys" 

91 } 

92 ) 

93 

94 # Neither JWT nor API key worked 

95 raise HTTPException( 

96 status_code=401, 

97 detail={ 

98 "error": "invalid_credentials", 

99 "message": "Invalid authentication token", 

100 "hint": "Use a valid JWT token or API key" 

101 } 

102 ) 

103 

104 

105async def get_current_user(user: Dict[str, Any] = Depends(verify_api_key)) -> Dict[str, Any]: 

106 """ 

107 Get current authenticated user. 

108 Convenience dependency that wraps verify_api_key. 

109 """ 

110 return user 

111 

112 

113async def get_current_user_no_rate_limit( 

114 authorization: Optional[str] = Header(None, alias="Authorization") 

115) -> Dict[str, Any]: 

116 """ 

117 Get current authenticated user WITHOUT rate limiting. 

118  

119 Use this for critical endpoints like billing/checkout where rate limits 

120 should not apply (users need to be able to purchase). 

121 """ 

122 if not authorization: 

123 raise HTTPException( 

124 status_code=401, 

125 detail={"error": "unauthorized", "message": "Authorization header required"} 

126 ) 

127 

128 if not authorization.startswith("Bearer "): 

129 raise HTTPException( 

130 status_code=401, 

131 detail={"error": "invalid_token", "message": "Invalid authorization format"} 

132 ) 

133 

134 token = authorization.replace("Bearer ", "") 

135 

136 # Verify API key (no rate limiting check!) 

137 user = await neon_service.verify_api_key(token) 

138 

139 if not user: 

140 raise HTTPException( 

141 status_code=401, 

142 detail={"error": "invalid_token", "message": "Invalid or expired API key"} 

143 ) 

144 

145 user["auth_type"] = "api_key" 

146 return user 

147 

148 

149# Optional: Public endpoint (no auth required) 

150async def optional_auth(authorization: Optional[str] = Header(None)) -> Optional[Dict[str, Any]]: 

151 """ 

152 Optional authentication. 

153 Returns user if authenticated, None if not. 

154 Used for endpoints that work with or without auth. 

155 """ 

156 if not authorization: 

157 return None 

158 

159 try: 

160 return await verify_api_key(authorization) 

161 except HTTPException: 

162 return None