@@ -74,7 +74,11 @@ def make_key_doc(revoked: bool = False, expires_at=None, scopes=None):
7474 )
7575
7676
77- def make_jwt_token (token_type : str = "access" , ttl_seconds : int = 900 ):
77+ def make_jwt_token (
78+ token_type : str = "access" ,
79+ ttl_seconds : int = 900 ,
80+ email : str | None = None ,
81+ ):
7882 now = datetime .now (timezone .utc )
7983 payload = {
8084 "sub" : str (USER_OID ),
@@ -85,6 +89,8 @@ def make_jwt_token(token_type: str = "access", ttl_seconds: int = 900):
8589 "iat" : now ,
8690 "exp" : now + timedelta (seconds = ttl_seconds ),
8791 }
92+ if email is not None :
93+ payload ["email" ] = email
8894 return pyjwt .encode (payload , JWT_SECRET , algorithm = "HS256" )
8995
9096
@@ -102,7 +108,7 @@ async def test_no_auth_returns_none(self):
102108 @pytest .mark .asyncio
103109 async def test_api_key_valid_returns_current_user (self ):
104110 key_doc = make_key_doc ()
105- user_mock = MagicMock (email_verified = True )
111+ user_mock = MagicMock (email_verified = True , email = "Owner@Example.com" )
106112
107113 with (
108114 patch ("dependencies.auth.get_settings" , return_value = make_settings ()),
@@ -119,6 +125,8 @@ async def test_api_key_valid_returns_current_user(self):
119125 assert result .user_id == USER_OID
120126 assert result .api_key_doc == key_doc
121127 assert result .email_verified is True
128+ # Email comes from the owning UserDoc (already fetched), lowercased.
129+ assert result .email == "owner@example.com"
122130
123131 @pytest .mark .asyncio
124132 async def test_api_key_revoked_returns_none (self ):
@@ -192,6 +200,85 @@ async def test_jwt_valid_returns_current_user(self):
192200 assert result .email_verified is True
193201 assert result .api_key_doc is None
194202
203+ @pytest .mark .asyncio
204+ async def test_jwt_email_claim_populates_current_user_lowercased (self ):
205+ token = make_jwt_token (email = "Alice@Example.COM" )
206+ req = make_request (auth_header = f"Bearer { token } " )
207+
208+ with patch ("dependencies.auth.get_settings" , return_value = make_settings ()):
209+ result = await get_current_user (req , db = MagicMock ())
210+
211+ assert result is not None
212+ assert result .email == "alice@example.com"
213+
214+ @pytest .mark .asyncio
215+ async def test_jwt_without_email_claim_yields_none_email (self ):
216+ # Old access tokens minted before the "email" claim existed must
217+ # still authenticate — email is simply None, never an error.
218+ token = make_jwt_token ()
219+ req = make_request (auth_header = f"Bearer { token } " )
220+
221+ with patch ("dependencies.auth.get_settings" , return_value = make_settings ()):
222+ result = await get_current_user (req , db = MagicMock ())
223+
224+ assert result is not None
225+ assert result .email is None
226+
227+ @pytest .mark .asyncio
228+ async def test_jwt_blank_email_claim_yields_none_email (self ):
229+ token = make_jwt_token (email = " " )
230+ req = make_request (auth_header = f"Bearer { token } " )
231+
232+ with patch ("dependencies.auth.get_settings" , return_value = make_settings ()):
233+ result = await get_current_user (req , db = MagicMock ())
234+
235+ assert result is not None
236+ assert result .email is None
237+
238+ @pytest .mark .asyncio
239+ @pytest .mark .parametrize ("bad_email" , [42 , ["alice@example.com" ]])
240+ async def test_jwt_non_string_email_claim_yields_none_email (self , bad_email ):
241+ # A token whose "email" claim is not a string (int, list, …) must
242+ # still authenticate — the claim parses to None, never an error.
243+ token = make_jwt_token (email = bad_email )
244+ req = make_request (auth_header = f"Bearer { token } " )
245+
246+ with patch ("dependencies.auth.get_settings" , return_value = make_settings ()):
247+ result = await get_current_user (req , db = MagicMock ())
248+
249+ assert result is not None
250+ assert result .user_id == USER_OID
251+ assert result .email is None
252+
253+ @pytest .mark .asyncio
254+ async def test_token_factory_round_trip_populates_email (self ):
255+ # Mint with the real TokenFactory → resolve via get_current_user:
256+ # the email claim survives the round trip and is lowercased.
257+ from schemas .models .user import UserDoc
258+ from services .token_factory import TokenFactory
259+
260+ user_doc = UserDoc .from_mongo (
261+ {
262+ "_id" : USER_OID ,
263+ "email" : "Round.Trip@Example.COM" ,
264+ "email_verified" : True ,
265+ "user_name" : "Round Trip" ,
266+ "created_at" : datetime .now (timezone .utc ),
267+ "updated_at" : datetime .now (timezone .utc ),
268+ }
269+ )
270+ token = TokenFactory (make_jwt_settings ()).generate_access_token (
271+ user_doc , amr = "pwd"
272+ )
273+ req = make_request (auth_header = f"Bearer { token } " )
274+
275+ with patch ("dependencies.auth.get_settings" , return_value = make_settings ()):
276+ result = await get_current_user (req , db = MagicMock ())
277+
278+ assert result is not None
279+ assert result .user_id == USER_OID
280+ assert result .email == "round.trip@example.com"
281+
195282 @pytest .mark .asyncio
196283 async def test_jwt_refresh_token_rejected (self ):
197284 token = make_jwt_token (token_type = "refresh" )
0 commit comments