@@ -139,27 +139,33 @@ def authenticate(req: falcon.Request) -> AuthContext:
139139
140140
141141class TestPkceHelpers :
142+ """Test PKCE code verifier, challenge, and state nonce generation."""
143+
142144 def test_code_verifier_length (self ) -> None :
145+ """Generated code verifier has valid RFC 7636 length."""
143146 cv = _generate_code_verifier ()
144147 assert 43 <= len (cv ) <= 128
145148
146149 def test_code_verifier_charset (self ) -> None :
150+ """Generated code verifier uses only URL-safe characters."""
147151 import re
148152
149153 cv = _generate_code_verifier ()
150154 assert re .fullmatch (r"[A-Za-z0-9_-]+" , cv )
151155
152156 def test_code_verifier_uniqueness (self ) -> None :
157+ """Each generated code verifier is unique."""
153158 verifiers = {_generate_code_verifier () for _ in range (100 )}
154159 assert len (verifiers ) == 100
155160
156161 def test_code_challenge_s256 (self ) -> None :
157- # RFC 7636 Appendix B test vector
162+ """S256 challenge matches RFC 7636 Appendix B test vector."""
158163 cv = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
159164 expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
160165 assert _generate_code_challenge (cv ) == expected
161166
162167 def test_state_nonce_uniqueness (self ) -> None :
168+ """Each generated state nonce is unique."""
163169 nonces = {_generate_state_nonce () for _ in range (100 )}
164170 assert len (nonces ) == 100
165171
@@ -170,10 +176,14 @@ def test_state_nonce_uniqueness(self) -> None:
170176
171177
172178class TestSignedSessionCookie :
179+ """Test signed OAuth session cookie pack/unpack and tamper detection."""
180+
173181 def setup_method (self ) -> None :
182+ """Derive session key for each test."""
174183 self .session_key = _derive_session_key (_SIGNING_KEY )
175184
176185 def test_roundtrip (self ) -> None :
186+ """Pack and unpack preserves all fields with empty return_to."""
177187 cv = "test-code-verifier"
178188 state = "test-state"
179189 url = "/vgi/"
@@ -185,6 +195,7 @@ def test_roundtrip(self) -> None:
185195 assert got_rt == ""
186196
187197 def test_roundtrip_with_return_to (self ) -> None :
198+ """Pack and unpack preserves return_to URL."""
188199 cv = "test-code-verifier"
189200 state = "test-state"
190201 url = "/vgi/"
@@ -197,6 +208,7 @@ def test_roundtrip_with_return_to(self) -> None:
197208 assert got_rt == rt
198209
199210 def test_tampered_rejected (self ) -> None :
211+ """Tampered cookie bytes are rejected by HMAC verification."""
200212 cookie = _pack_oauth_cookie ("cv" , "st" , "/" , self .session_key )
201213 # Flip a bit
202214 raw = base64 .urlsafe_b64decode (cookie )
@@ -206,18 +218,21 @@ def test_tampered_rejected(self) -> None:
206218 _unpack_oauth_cookie (tampered_cookie , self .session_key )
207219
208220 def test_expired_rejected (self ) -> None :
221+ """Expired cookies are rejected based on max_age."""
209222 old_time = int (time .time ()) - 700 # 700s ago, max_age=600
210223 cookie = _pack_oauth_cookie ("cv" , "st" , "/" , self .session_key , created_at = old_time )
211224 with pytest .raises (ValueError , match = "expired" ):
212225 _unpack_oauth_cookie (cookie , self .session_key , max_age = 600 )
213226
214227 def test_wrong_key_rejected (self ) -> None :
228+ """Cookie signed with a different key is rejected."""
215229 cookie = _pack_oauth_cookie ("cv" , "st" , "/" , self .session_key )
216230 wrong_key = _derive_session_key (b"wrong-key-wrong-key-wrong-key!!" )
217231 with pytest .raises (ValueError , match = "signature mismatch" ):
218232 _unpack_oauth_cookie (cookie , wrong_key )
219233
220234 def test_truncated_rejected (self ) -> None :
235+ """Truncated cookie data is rejected as too short."""
221236 with pytest .raises (ValueError , match = "too short" ):
222237 _unpack_oauth_cookie ("dG9vc2hvcnQ=" , self .session_key )
223238
@@ -241,24 +256,32 @@ def test_cross_protocol_rejected(self) -> None:
241256
242257
243258class TestValidateOriginalUrl :
259+ """Test original URL validation for redirect safety."""
260+
244261 def test_relative_url_passes (self ) -> None :
262+ """Relative URL within prefix is accepted."""
245263 assert _validate_original_url ("/vgi/" , "/vgi" ) == "/vgi/"
246264
247265 def test_with_query_string (self ) -> None :
266+ """URL with query string is preserved."""
248267 assert _validate_original_url ("/vgi/?foo=bar" , "/vgi" ) == "/vgi/?foo=bar"
249268
250269 def test_absolute_url_rejected (self ) -> None :
270+ """Absolute URL is rejected as potential open redirect."""
251271 assert _validate_original_url ("https://evil.com/steal" , "/vgi" ) == "/vgi"
252272
253273 def test_wrong_prefix_rejected (self ) -> None :
274+ """URL outside expected prefix falls back to prefix root."""
254275 assert _validate_original_url ("/other/path" , "/vgi" ) == "/vgi"
255276
256277 def test_long_url_truncated (self ) -> None :
278+ """URLs exceeding max length are truncated."""
257279 long_url = "/vgi/" + "a" * 3000
258280 result = _validate_original_url (long_url , "/vgi" )
259281 assert len (result ) <= 2048
260282
261283 def test_empty_prefix (self ) -> None :
284+ """Empty prefix allows any relative URL."""
262285 assert _validate_original_url ("/anything" , "" ) == "/anything"
263286
264287
@@ -282,7 +305,10 @@ def _make_request(
282305
283306
284307class TestCookieAuthenticate :
308+ """Test cookie-based authentication fallback via make_cookie_authenticate."""
309+
285310 def setup_method (self ) -> None :
311+ """Generate RSA keys and create local authenticator."""
286312 self .priv , self .pub = _make_rsa_key ()
287313 self .inner = _make_local_auth (self .pub )
288314
@@ -346,7 +372,7 @@ def test_header_restored_after_cookie_auth(self) -> None:
346372
347373
348374def _make_test_app (
349- authenticate : Callable | None = None ,
375+ authenticate : Callable [..., AuthContext ] | None = None ,
350376 oauth_metadata : OAuthResourceMetadata | None = None ,
351377 prefix : str = "/vgi" ,
352378) -> falcon .testing .TestClient :
@@ -378,12 +404,13 @@ class TestPkceRedirect:
378404 """Test that browser GETs get redirected to OAuth when unauthenticated."""
379405
380406 def setup_method (self ) -> None :
407+ """Generate RSA keys and create local authenticator."""
381408 self .priv , self .pub = _make_rsa_key ()
382409 self .auth = _make_local_auth (self .pub )
383410
384411 @patch ("vgi_rpc.http._oauth_pkce.httpx.Client" )
385- def test_browser_get_redirects (self , mock_client_cls ) -> None :
386- """Unauthenticated browser GET → 302 to authorization endpoint."""
412+ def test_browser_get_redirects (self , mock_client_cls ) -> None : # type: ignore[no-untyped-def]
413+ """Unauthenticated browser GET -> 302 to authorization endpoint."""
387414 mock_resp = type (
388415 "R" , (), {"status_code" : 200 , "raise_for_status" : lambda s : None , "json" : lambda s : _MOCK_OIDC_CONFIG }
389416 )()
@@ -409,8 +436,8 @@ def test_browser_get_redirects(self, mock_client_cls) -> None:
409436 assert _SESSION_COOKIE_NAME in result .cookies
410437
411438 @patch ("vgi_rpc.http._oauth_pkce.httpx.Client" )
412- def test_non_browser_get_gets_401 (self , mock_client_cls ) -> None :
413- """Non-browser GET (no text/html Accept) → 401, no redirect."""
439+ def test_non_browser_get_gets_401 (self , mock_client_cls ) -> None : # type: ignore[no-untyped-def]
440+ """Non-browser GET (no text/html Accept) -> 401, no redirect."""
414441 mock_resp = type (
415442 "R" , (), {"status_code" : 200 , "raise_for_status" : lambda s : None , "json" : lambda s : _MOCK_OIDC_CONFIG }
416443 )()
@@ -424,8 +451,8 @@ def test_non_browser_get_gets_401(self, mock_client_cls) -> None:
424451 assert result .status_code == 401
425452
426453 @patch ("vgi_rpc.http._oauth_pkce.httpx.Client" )
427- def test_post_gets_401 (self , mock_client_cls ) -> None :
428- """POST requests → 401, no redirect even with text/html Accept."""
454+ def test_post_gets_401 (self , mock_client_cls ) -> None : # type: ignore[no-untyped-def]
455+ """POST requests -> 401, no redirect even with text/html Accept."""
429456 mock_resp = type (
430457 "R" , (), {"status_code" : 200 , "raise_for_status" : lambda s : None , "json" : lambda s : _MOCK_OIDC_CONFIG }
431458 )()
@@ -436,11 +463,11 @@ def test_post_gets_401(self, mock_client_cls) -> None:
436463
437464 client = _make_test_app (authenticate = self .auth , oauth_metadata = _METADATA_PKCE )
438465 result = client .simulate_post ("/vgi/echo" , headers = {"Accept" : "text/html" })
439- # POST to RPC endpoint without auth → 401 (not redirected)
466+ # POST to RPC endpoint without auth -> 401 (not redirected)
440467 assert result .status_code == 401
441468
442469 def test_authenticated_browser_get_succeeds (self ) -> None :
443- """Browser GET with valid cookie → 200 (page served)."""
470+ """Browser GET with valid cookie -> 200 (page served)."""
444471 token = _mint_jwt (self .priv )
445472
446473 # Need to mock OIDC discovery since make_wsgi_app with PKCE creates the discovery callable
@@ -470,16 +497,21 @@ def test_authenticated_browser_get_succeeds(self) -> None:
470497
471498
472499class TestOAuthCallback :
500+ """Test OAuth callback endpoint handling."""
501+
473502 def setup_method (self ) -> None :
503+ """Generate RSA keys and derive session key."""
474504 self .priv , self .pub = _make_rsa_key ()
475505 self .auth = _make_local_auth (self .pub )
476506 self .session_key = _derive_session_key (_SIGNING_KEY )
477507
478508 def _make_session_cookie (self , state : str = "test-state" , url : str = "/vgi/" ) -> str :
509+ """Create a signed session cookie for testing."""
479510 return _pack_oauth_cookie ("test-verifier" , state , url , self .session_key )
480511
481512 @patch ("vgi_rpc.http._oauth_pkce.httpx.Client" )
482- def test_missing_code_returns_400 (self , mock_client_cls ) -> None :
513+ def test_missing_code_returns_400 (self , mock_client_cls ) -> None : # type: ignore[no-untyped-def]
514+ """Missing authorization code in callback returns 400."""
483515 mock_resp = type (
484516 "R" , (), {"status_code" : 200 , "raise_for_status" : lambda s : None , "json" : lambda s : _MOCK_OIDC_CONFIG }
485517 )()
@@ -493,7 +525,8 @@ def test_missing_code_returns_400(self, mock_client_cls) -> None:
493525 assert result .status_code == 400
494526
495527 @patch ("vgi_rpc.http._oauth_pkce.httpx.Client" )
496- def test_google_error_returns_400 (self , mock_client_cls ) -> None :
528+ def test_google_error_returns_400 (self , mock_client_cls ) -> None : # type: ignore[no-untyped-def]
529+ """Authorization server error in callback returns 400 with message."""
497530 mock_resp = type (
498531 "R" , (), {"status_code" : 200 , "raise_for_status" : lambda s : None , "json" : lambda s : _MOCK_OIDC_CONFIG }
499532 )()
@@ -511,7 +544,8 @@ def test_google_error_returns_400(self, mock_client_cls) -> None:
511544 assert b"authorization server returned an error" in result .content
512545
513546 @patch ("vgi_rpc.http._oauth_pkce.httpx.Client" )
514- def test_state_mismatch_returns_400 (self , mock_client_cls ) -> None :
547+ def test_state_mismatch_returns_400 (self , mock_client_cls ) -> None : # type: ignore[no-untyped-def]
548+ """State parameter mismatch (CSRF) returns 400."""
515549 mock_resp = type (
516550 "R" , (), {"status_code" : 200 , "raise_for_status" : lambda s : None , "json" : lambda s : _MOCK_OIDC_CONFIG }
517551 )()
@@ -532,7 +566,8 @@ def test_state_mismatch_returns_400(self, mock_client_cls) -> None:
532566
533567 @patch ("vgi_rpc.http._oauth_pkce._exchange_code_for_token" )
534568 @patch ("vgi_rpc.http._oauth_pkce.httpx.Client" )
535- def test_successful_callback_redirects_with_cookie (self , mock_client_cls , mock_exchange ) -> None :
569+ def test_successful_callback_redirects_with_cookie (self , mock_client_cls , mock_exchange ) -> None : # type: ignore[no-untyped-def]
570+ """Successful code exchange redirects with auth cookie set."""
536571 mock_resp = type (
537572 "R" , (), {"status_code" : 200 , "raise_for_status" : lambda s : None , "json" : lambda s : _MOCK_OIDC_CONFIG }
538573 )()
@@ -564,12 +599,16 @@ def test_successful_callback_redirects_with_cookie(self, mock_client_cls, mock_e
564599
565600
566601class TestOAuthLogout :
602+ """Test OAuth logout endpoint clears cookies and redirects."""
603+
567604 def setup_method (self ) -> None :
605+ """Generate RSA keys and create local authenticator."""
568606 self .priv , self .pub = _make_rsa_key ()
569607 self .auth = _make_local_auth (self .pub )
570608
571609 @patch ("vgi_rpc.http._oauth_pkce.httpx.Client" )
572- def test_logout_clears_cookie_and_redirects (self , mock_client_cls ) -> None :
610+ def test_logout_clears_cookie_and_redirects (self , mock_client_cls ) -> None : # type: ignore[no-untyped-def]
611+ """Logout clears auth cookie and redirects to prefix root."""
573612 mock_resp = type (
574613 "R" , (), {"status_code" : 200 , "raise_for_status" : lambda s : None , "json" : lambda s : _MOCK_OIDC_CONFIG }
575614 )()
@@ -590,26 +629,29 @@ def test_logout_clears_cookie_and_redirects(self, mock_client_cls) -> None:
590629
591630
592631class TestNonOAuthAuthSchemes :
632+ """Test that PKCE does not interfere with non-OAuth auth schemes."""
633+
593634 def setup_method (self ) -> None :
635+ """Generate RSA keys and create local authenticator."""
594636 self .priv , self .pub = _make_rsa_key ()
595637 self .auth = _make_local_auth (self .pub )
596638
597639 def test_bearer_only_no_redirect (self ) -> None :
598- """Auth without oauth_resource_metadata → plain 401, no redirect."""
640+ """Auth without oauth_resource_metadata -> plain 401, no redirect."""
599641 client = _make_test_app (authenticate = self .auth , oauth_metadata = None )
600642 result = client .simulate_get ("/vgi" , headers = {"Accept" : "text/html" })
601643 assert result .status_code == 401
602644 assert "location" not in result .headers
603645
604646 def test_metadata_without_client_id_no_redirect (self ) -> None :
605- """OAuth metadata without client_id → plain 401, no redirect."""
647+ """OAuth metadata without client_id -> plain 401, no redirect."""
606648 client = _make_test_app (authenticate = self .auth , oauth_metadata = _METADATA_NO_CLIENT_ID )
607649 result = client .simulate_get ("/vgi" , headers = {"Accept" : "text/html" })
608650 assert result .status_code == 401
609651 assert "location" not in result .headers
610652
611653 def test_no_auth_pages_public (self ) -> None :
612- """No authenticate → pages are public."""
654+ """No authenticate -> pages are public."""
613655 client = _make_test_app (authenticate = None , oauth_metadata = None )
614656 result = client .simulate_get ("/vgi" , headers = {"Accept" : "text/html" })
615657 assert result .status_code == 200
@@ -621,12 +663,15 @@ def test_no_auth_pages_public(self) -> None:
621663
622664
623665class TestUserInfoInjection :
666+ """Test user info JS injection on landing pages when PKCE is active."""
667+
624668 def setup_method (self ) -> None :
669+ """Generate RSA keys and create local authenticator."""
625670 self .priv , self .pub = _make_rsa_key ()
626671 self .auth = _make_local_auth (self .pub )
627672
628673 @patch ("vgi_rpc.http._oauth_pkce.httpx.Client" )
629- def test_landing_page_has_user_info_script (self , mock_client_cls ) -> None :
674+ def test_landing_page_has_user_info_script (self , mock_client_cls ) -> None : # type: ignore[no-untyped-def]
630675 """When PKCE is active, landing page includes user-info JS."""
631676 mock_resp = type (
632677 "R" , (), {"status_code" : 200 , "raise_for_status" : lambda s : None , "json" : lambda s : _MOCK_OIDC_CONFIG }
0 commit comments