Skip to content

Commit a41ec7e

Browse files
rustyconoverclaude
andcommitted
Fix lint errors: add docstrings to test_oauth_pkce.py and fix D205 in _oauth_pkce.py
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7906e46 commit a41ec7e

2 files changed

Lines changed: 65 additions & 21 deletions

File tree

tests/test_oauth_pkce.py

Lines changed: 64 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -139,27 +139,33 @@ def authenticate(req: falcon.Request) -> AuthContext:
139139

140140

141141
class 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

172178
class 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

243258
class 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

284307
class 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

348374
def _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

472499
class 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

566601
class 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

592631
class 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

623665
class 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}

vgi_rpc/http/_oauth_pkce.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -619,8 +619,7 @@ def on_get(self, req: falcon.Request, resp: falcon.Response) -> None:
619619

620620

621621
class _OAuthPkceMiddleware:
622-
"""Falcon middleware that intercepts 401s on browser GETs and redirects
623-
to the OAuth authorization endpoint with PKCE.
622+
"""Falcon middleware that intercepts 401s on browser GETs and redirects to OAuth.
624623
625624
Only activates when the response is 401, the request is GET, and the
626625
Accept header contains ``text/html``.

0 commit comments

Comments
 (0)