eager-auth 2.2.0__tar.gz → 2.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {eager_auth-2.2.0 → eager_auth-2.2.2}/PKG-INFO +2 -2
  2. {eager_auth-2.2.0 → eager_auth-2.2.2}/pyproject.toml +4 -2
  3. {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/fastapi.py +91 -6
  4. {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/pending.py +23 -5
  5. {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/tokens.py +3 -1
  6. {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_fastapi.py +146 -9
  7. eager_auth-2.2.2/tests/test_hostile_tokens.py +32 -0
  8. {eager_auth-2.2.0 → eager_auth-2.2.2}/.gitignore +0 -0
  9. {eager_auth-2.2.0 → eager_auth-2.2.2}/LICENSE +0 -0
  10. {eager_auth-2.2.0 → eager_auth-2.2.2}/README.md +0 -0
  11. {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/__init__.py +0 -0
  12. {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/claims.py +0 -0
  13. {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/config.py +0 -0
  14. {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/context.py +0 -0
  15. {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/discovery.py +0 -0
  16. {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/errors.py +0 -0
  17. {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/flow.py +0 -0
  18. {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/keys.py +0 -0
  19. {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/presets.py +0 -0
  20. {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/principal.py +0 -0
  21. {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/session.py +0 -0
  22. {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/conftest.py +0 -0
  23. {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_callback.py +0 -0
  24. {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_config.py +0 -0
  25. {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_conformance.py +0 -0
  26. {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_flow.py +0 -0
  27. {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_http_jwks_source.py +0 -0
  28. {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_keys.py +0 -0
  29. {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_lifecycle.py +0 -0
  30. {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_session.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: eager-auth
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Summary: Standard OIDC relying party for Eager FastAPI services — verification core
5
5
  Project-URL: Homepage, https://github.com/Whats-Next-CC/eager-auth
6
6
  Project-URL: Source, https://github.com/Whats-Next-CC/eager-auth
@@ -20,7 +20,7 @@ Classifier: Typing :: Typed
20
20
  Requires-Python: >=3.12
21
21
  Requires-Dist: httpx>=0.27
22
22
  Requires-Dist: joserfc>=1.0
23
- Requires-Dist: pyjwt[crypto]>=2.9
23
+ Requires-Dist: pyjwt[crypto]>=2.15.0
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: black>=24.0; extra == 'dev'
26
26
  Requires-Dist: fastapi>=0.110; extra == 'dev'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "eager-auth"
3
- version = "2.2.0"
3
+ version = "2.2.2"
4
4
  description = "Standard OIDC relying party for Eager FastAPI services — verification core"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -20,7 +20,9 @@ classifiers = [
20
20
  license = "Apache-2.0"
21
21
  license-files = ["LICENSE"]
22
22
  dependencies = [
23
- "pyjwt[crypto]>=2.9",
23
+ # 2.15.0 is the first release with no open advisory: of the thirteen published on
24
+ # 2026-09-29/30, 2.14.0 fixed twelve and GHSA-42vr-xj54-vc7v still affects 2.14.0.
25
+ "pyjwt[crypto]>=2.15.0",
24
26
  "httpx>=0.27",
25
27
  "joserfc>=1.0",
26
28
  ]
@@ -19,11 +19,13 @@ pulling a web framework in.
19
19
  from __future__ import annotations
20
20
 
21
21
  from collections.abc import Callable
22
+ from html import escape
22
23
  from urllib.parse import quote, urlsplit
23
24
 
24
25
  from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
25
- from fastapi.responses import JSONResponse, RedirectResponse
26
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
26
27
 
28
+ from eager_auth.config import AuthConfig
27
29
  from eager_auth.context import AuthContext
28
30
  from eager_auth.errors import AuthError, DiscoveryError
29
31
  from eager_auth.flow import complete_login, logout_url, start_login
@@ -58,6 +60,67 @@ def _clear(response: Response, attrs: CookieAttributes) -> None:
58
60
  response.delete_cookie(attrs.name, path=attrs.path)
59
61
 
60
62
 
63
+ #: What a GET on the logout route answers: one form posting back, and no script, so nothing on
64
+ #: the page can submit it for the person. CONTRACT.md section 10.
65
+ _LOGOUT_PAGE = (
66
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">'
67
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
68
+ "<title>Sign out</title></head><body>"
69
+ '<form method="post" action="{action}"><p>Sign out?</p>'
70
+ '<button type="submit">Sign out</button></form></body></html>'
71
+ )
72
+
73
+ #: Not cached, and never framed: a framing site could otherwise place the button under a click.
74
+ _LOGOUT_PAGE_HEADERS = {
75
+ "Cache-Control": "no-store",
76
+ "X-Frame-Options": "DENY",
77
+ # No form-action: Chromium applies it to the redirect after the POST, which would block the
78
+ # hand-off to the broker.
79
+ "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
80
+ }
81
+
82
+ _DEFAULT_PORTS = {"http": 80, "https": 443}
83
+
84
+ # What a path may carry unencoded (RFC 3986 pchar minus "%"); everything else is re-encoded.
85
+ _PATH_SAFE = "/:@!$&'()*+,;=-._~"
86
+
87
+
88
+ def _origin_of(url: str) -> str | None:
89
+ """`scheme://host[:port]`, lower-cased with the default port dropped, or None."""
90
+ try:
91
+ parts = urlsplit(url)
92
+ port = parts.port
93
+ except ValueError:
94
+ return None
95
+ if parts.scheme not in _DEFAULT_PORTS or not parts.hostname:
96
+ return None
97
+ try:
98
+ # Browsers serialise an internationalised host as punycode in Origin.
99
+ host = parts.hostname.encode("idna").decode("ascii")
100
+ except UnicodeError:
101
+ return None
102
+ if port is None or port == _DEFAULT_PORTS[parts.scheme]:
103
+ return f"{parts.scheme}://{host}"
104
+ return f"{parts.scheme}://{host}:{port}"
105
+
106
+
107
+ def came_from_this_site(request: Request, config: AuthConfig) -> bool:
108
+ """Whether the browser says this request was made by a page of this service.
109
+
110
+ The site is the origin of `redirect_uri`, never the request host, which behind a load
111
+ balancer is the internal one. `Origin` decides when it is a real origin; when it is absent
112
+ or opaque (`null`) only `Sec-Fetch-Site: same-origin` will do. A request carrying neither
113
+ is refused, since every current browser sends one of them on a form POST.
114
+ """
115
+ origin = request.headers.get("origin")
116
+ # "null" is sent on every POST from a page with Referrer-Policy: no-referrer, same-origin
117
+ # included, so an opaque Origin says nothing and fetch metadata decides instead.
118
+ if origin is not None and origin != "null":
119
+ site = _origin_of(config.redirect_uri) if config.redirect_uri else None
120
+ return site is not None and _origin_of(origin) == site
121
+ return request.headers.get("sec-fetch-site") == "same-origin"
122
+
123
+
61
124
  def _read_principal(request: Request, ctx: AuthContext) -> Principal | None:
62
125
  """Return the principal this request carries, or None.
63
126
 
@@ -155,8 +218,13 @@ def _destination(request: Request) -> str | None:
155
218
  query = f"?{parts.query}" if parts.query else ""
156
219
  return safe_next(f"{parts.path}{query}")
157
220
 
221
+ # `request.url.path` is decoded, so "/a%20b" would reach safe_next as "/a b" and be dropped,
222
+ # while the TypeScript gate sees the encoded path and keeps it: the same person would land
223
+ # on different pages. Encoding it again gives safe_next what the browser sent, the way the
224
+ # WHATWG URL in the TypeScript gate does. A "%" is re-encoded too, so nothing decodes twice.
225
+ path = quote(request.url.path, safe=_PATH_SAFE)
158
226
  query = f"?{request.url.query}" if request.url.query else ""
159
- return safe_next(f"{request.url.path}{query}")
227
+ return safe_next(f"{path}{query}")
160
228
 
161
229
 
162
230
  def _login_path(request: Request, ctx: AuthContext) -> str:
@@ -200,10 +268,12 @@ def create_auth_router(ctx: AuthContext) -> APIRouter:
200
268
 
201
269
  @router.get("/callback")
202
270
  def callback(request: Request, code: str | None = None, state: str | None = None) -> Response:
203
- """Finish a login. Any failure clears both cookies and starts over."""
271
+ """Finish a login. Any failure clears the pending cookie and starts over."""
204
272
  failure = RedirectResponse(f"{ctx.config.route_prefix}/login", status_code=307)
273
+ # Only the pending cookie is cleared. A failed callback is reachable by any top-level
274
+ # link, so clearing the session here would let any site sign anybody out (CONTRACT.md
275
+ # section 10).
205
276
  _clear(failure, pending_cookie(ctx.config))
206
- _clear(failure, session_cookie(ctx.config))
207
277
 
208
278
  if not code or not state:
209
279
  return failure
@@ -250,9 +320,23 @@ def create_auth_router(ctx: AuthContext) -> APIRouter:
250
320
  _clear(response, pending_cookie(ctx.config))
251
321
  return response
252
322
 
253
- @router.api_route("/logout", methods=["GET", "POST"])
323
+ @router.api_route("/logout", methods=["GET", "HEAD"])
324
+ def confirm_logout(request: Request) -> Response:
325
+ """Answer a page with one button, so a link works and a cross-site GET changes nothing."""
326
+ local = safe_next(request.query_params.get("next"))
327
+ action = f"{ctx.config.route_prefix}/logout"
328
+ if local:
329
+ action += f"?next={quote(local, safe='')}"
330
+ return HTMLResponse(
331
+ _LOGOUT_PAGE.format(action=escape(action, quote=True)),
332
+ headers=_LOGOUT_PAGE_HEADERS,
333
+ )
334
+
335
+ @router.post("/logout")
254
336
  def logout(request: Request) -> Response:
255
337
  """Clear our session, then hand off to the broker when it offers a logout."""
338
+ if not came_from_this_site(request, ctx.config):
339
+ return JSONResponse({"detail": "logout must come from this site"}, status_code=403)
256
340
  # `next` is honoured for a local logout. With a broker the landing page is whatever
257
341
  # post_logout_redirect_uri the broker has registered — it cannot be a relative path,
258
342
  # and it must not come from the request host (behind a load balancer that is internal).
@@ -264,7 +348,8 @@ def create_auth_router(ctx: AuthContext) -> APIRouter:
264
348
  request.cookies.get(ctx.config.logout_cookie_name), config=ctx.config
265
349
  )
266
350
  broker = logout_url(ctx.config, ctx.discovery, id_token_hint=hint)
267
- response = RedirectResponse(broker or local, status_code=307)
351
+ # 303: the browser follows with GET, so the broker receives its query parameters.
352
+ response = RedirectResponse(broker or local, status_code=303)
268
353
  _clear(response, session_cookie(ctx.config))
269
354
  _clear(response, pending_cookie(ctx.config))
270
355
  _clear(response, logout_cookie(ctx.config))
@@ -14,7 +14,9 @@ from __future__ import annotations
14
14
 
15
15
  import datetime as dt
16
16
  import json
17
+ import re
17
18
  from dataclasses import dataclass
19
+ from urllib.parse import urljoin, urlsplit
18
20
 
19
21
  from joserfc import jwe
20
22
  from joserfc.errors import JoseError
@@ -28,16 +30,32 @@ from eager_auth.session import session_key
28
30
  _HEADER = {"alg": "dir", "enc": "A256GCM"}
29
31
 
30
32
 
33
+ # "/" and then printable ASCII only (0x21-0x7E), never "//" and never a backslash anywhere.
34
+ _PLAIN_PATH = re.compile(r"/(?![/\\])[\x21-\x7e]*")
35
+
36
+ # Any origin will do for the second check: a value resolves to its base's origin against every
37
+ # base, or against none. The configured one is not needed, so neither is a config parameter.
38
+ _PROBE_ORIGIN = "https://origin.invalid"
39
+
40
+
31
41
  def safe_next(candidate: str | None) -> str | None:
32
- """Accept only a same-site relative path.
42
+ """Accept only a path of this site.
33
43
 
34
44
  Anything absolute, scheme-relative or backslash-escaped is dropped rather than corrected.
35
- Silently sending a freshly authenticated user to an attacker's host is the exact shape of
36
- a post-login open redirect.
45
+ So is anything outside printable ASCII: a browser removes tabs and line breaks while it
46
+ parses a Location, so "/<TAB>/evil.example" would arrive as "//evil.example" and leave the
47
+ site. Percent-encoded bytes stay encoded in a Location and are kept. Silently sending a
48
+ freshly authenticated user to an attacker's host is the exact shape of a post-login open
49
+ redirect.
37
50
  """
38
- if not candidate or not candidate.startswith("/"):
51
+ if not candidate or "\\" in candidate:
52
+ return None
53
+ if not _PLAIN_PATH.fullmatch(candidate):
39
54
  return None
40
- if candidate.startswith("//") or candidate.startswith("/\\"):
55
+ # Belt and braces: the pattern above already rules out every way of leaving the origin
56
+ # known today. Resolving it the way a browser would catches the one not known yet.
57
+ resolved = urlsplit(urljoin(_PROBE_ORIGIN + "/", candidate))
58
+ if f"{resolved.scheme}://{resolved.netloc}" != _PROBE_ORIGIN:
41
59
  return None
42
60
  return candidate
43
61
 
@@ -74,7 +74,9 @@ def _header_algorithm(token: str, config: AuthConfig) -> str:
74
74
  """Read `alg` from the header and refuse anything symmetric, before touching keys."""
75
75
  try:
76
76
  header = jwt.get_unverified_header(token)
77
- except jwt.InvalidTokenError as exc:
77
+ # RecursionError: a deeply nested header is attacker input read before any signature
78
+ # check, and PyJWT has let json.loads raise it unwrapped (GHSA-8wjv-2p76-3863).
79
+ except (jwt.InvalidTokenError, RecursionError) as exc:
78
80
  raise MalformedTokenError(f"cannot read token header: {exc}") from exc
79
81
 
80
82
  algorithm = header.get("alg")
@@ -28,11 +28,14 @@ from eager_auth import (
28
28
  )
29
29
  from eager_auth.fastapi import create_auth_router, current_principal, require_role
30
30
  from eager_auth.flow import PendingLogin
31
+ from eager_auth.pending import unseal_pending
31
32
 
32
33
  VECTORS = json.loads(
33
34
  (Path(__file__).resolve().parents[3] / "conformance" / "flow-vectors.json").read_text()
34
35
  )
35
36
  KEY = VECTORS["session_interop"]["key_b64url"]
37
+ #: What a browser sends on a form POST from a page of this service.
38
+ SAME_ORIGIN = {"origin": VECTORS["logout_request"]["site"]}
36
39
 
37
40
 
38
41
  @pytest.fixture
@@ -103,6 +106,10 @@ def client(ctx) -> TestClient:
103
106
  def private(user: Principal = Depends(current_principal(ctx))): # noqa: B008
104
107
  return {"sub": user.sub, "roles": list(user.roles)}
105
108
 
109
+ @app.get("/docs/{name}")
110
+ def doc(name: str, user: Principal = Depends(current_principal(ctx))): # noqa: B008
111
+ return {"name": name}
112
+
106
113
  @app.get("/admin")
107
114
  def admin(user: Principal = Depends(require_role(ctx, "admin"))): # noqa: B008
108
115
  return {"ok": True}
@@ -187,6 +194,16 @@ def test_a_broken_callback_starts_over_and_leaves_no_session(client, config, que
187
194
  assert not response.cookies.get(config.cookie_name), reason
188
195
 
189
196
 
197
+ def test_a_failed_callback_does_not_end_an_existing_session(client, config):
198
+ """A top-level link to the callback is a cross-site GET; it must not sign anybody out."""
199
+ client.cookies.set(config.cookie_name, session_cookie_value(config))
200
+
201
+ response = client.get(f"{config.route_prefix}/callback?code=abc&state=forged")
202
+
203
+ assert response.headers["location"].endswith("/login")
204
+ assert not _clears_session(response, config)
205
+
206
+
190
207
  def test_a_callback_without_the_pending_cookie_is_refused(client, config):
191
208
  """Someone replaying a callback URL has no sealed state and cannot be trusted."""
192
209
  response = client.get(f"{config.route_prefix}/callback?code=abc&state=the-state")
@@ -216,6 +233,15 @@ def test_next_accepts_relative_paths(candidate):
216
233
  assert safe_next(candidate) == candidate
217
234
 
218
235
 
236
+ NEXT_CASES = VECTORS["next_path"]["cases"]
237
+
238
+
239
+ @pytest.mark.parametrize("case", NEXT_CASES, ids=[c["name"] for c in NEXT_CASES])
240
+ def test_next_matches_the_shared_matrix(case):
241
+ """The TypeScript adapter runs the same cases; the two must not disagree on a path."""
242
+ assert safe_next(case["candidate"]) == case["expect"]
243
+
244
+
219
245
  def test_a_hostile_next_does_not_survive_the_round_trip(client, config):
220
246
  pending = PendingLogin(
221
247
  authorization_url="", state="the-state", nonce="the-nonce", code_verifier="v" * 43
@@ -229,6 +255,41 @@ def test_a_hostile_next_does_not_survive_the_round_trip(client, config):
229
255
  assert response.headers["location"] == "/", "hostile next dropped, not followed"
230
256
 
231
257
 
258
+ HOSTILE_LOGIN_NEXT = [
259
+ "%2F%2Fevil.test",
260
+ "/%09/evil.test",
261
+ "/%0A/evil.test",
262
+ "/%5Cevil.test",
263
+ "https%3A%2F%2Fevil.test%2F",
264
+ ]
265
+
266
+
267
+ @pytest.mark.parametrize("encoded", HOSTILE_LOGIN_NEXT)
268
+ def test_login_decodes_next_before_it_judges_it(client, config, encoded):
269
+ """The query is decoded first, so an encoded tab or "//" is judged as what it becomes."""
270
+ response = client.get(f"{config.route_prefix}/login?next={encoded}")
271
+
272
+ pending = unseal_pending(response.cookies[config.pending_cookie_name], config=config)
273
+ assert pending.next_path is None
274
+
275
+
276
+ def test_login_keeps_a_plain_next_with_its_query(client, config):
277
+ response = client.get(f"{config.route_prefix}/login?next=%2Freports%3Fpage%3D2")
278
+
279
+ pending = unseal_pending(response.cookies[config.pending_cookie_name], config=config)
280
+ assert pending.next_path == "/reports?page=2"
281
+
282
+
283
+ @pytest.mark.parametrize(
284
+ ("path", "expected"),
285
+ [("/docs/a%20b", "/docs/a%20b"), ("/docs/caf%C3%A9", "/docs/caf%C3%A9")],
286
+ )
287
+ def test_the_gate_keeps_an_encoded_path_encoded(client, path, expected):
288
+ """Judged as the browser sent it, as the TypeScript gate does, not decoded and dropped."""
289
+ target = urlparse(client.get(path).headers["location"])
290
+ assert parse_qs(target.query)["next"] == [expected]
291
+
292
+
232
293
  # --- guarding routes ----------------------------------------------------------------------
233
294
 
234
295
 
@@ -365,11 +426,80 @@ def test_me_without_a_session_is_a_401_a_frontend_can_branch_on(client, config):
365
426
  assert response.json() == {"authenticated": False}
366
427
 
367
428
 
429
+ LOGOUT_CASES = VECTORS["logout_request"]["cases"]
430
+
431
+
432
+ def _clears_session(response, config) -> bool:
433
+ return any(
434
+ header.startswith(f"{config.cookie_name}=") and "max-age=0" in header.lower()
435
+ for header in response.headers.get_list("set-cookie")
436
+ )
437
+
438
+
439
+ @pytest.mark.parametrize("case", LOGOUT_CASES, ids=[c["name"] for c in LOGOUT_CASES])
440
+ def test_logout_matches_the_shared_matrix(case, client, config):
441
+ """Who may sign a person out. The TypeScript adapter runs the same cases."""
442
+ client.cookies.set(config.cookie_name, session_cookie_value(config))
443
+ response = client.request(
444
+ case["method"], f"{config.route_prefix}/logout", headers=case["headers"]
445
+ )
446
+ expected = case["expect"]
447
+
448
+ assert response.status_code == expected["status"]
449
+ assert _clears_session(response, config) is expected["signed_out"]
450
+ if expected["signed_out"]:
451
+ assert response.headers["location"].startswith(VECTORS["discovery"]["end_session_endpoint"])
452
+ if "content_type" in expected:
453
+ assert response.headers["content-type"].startswith(expected["content_type"])
454
+ assert "no-store" in response.headers["cache-control"]
455
+ assert f'action="{expected["form_action"]}"' in response.text
456
+ assert 'method="post"' in response.text
457
+ assert "<script" not in response.text.lower()
458
+ if expected.get("no_framing"):
459
+ assert response.headers["x-frame-options"] == "DENY"
460
+ assert "frame-ancestors 'none'" in response.headers["content-security-policy"]
461
+ for directive in expected.get("csp_omits", []):
462
+ assert directive not in response.headers["content-security-policy"]
463
+
464
+
465
+ def test_a_service_without_a_redirect_uri_trusts_only_fetch_metadata(
466
+ ctx, config, discovery_for_ctx
467
+ ):
468
+ """No configured site means no Origin can match it; same-origin fetch metadata still can."""
469
+ worker = AuthContext(
470
+ replace(config, redirect_uri=None), keys=ctx.keys, discovery=discovery_for_ctx
471
+ )
472
+ app = FastAPI()
473
+ app.include_router(create_auth_router(worker))
474
+ client = TestClient(app, follow_redirects=False)
475
+ path = f"{config.route_prefix}/logout"
476
+
477
+ assert client.post(path, headers={"origin": "https://svc.example.test"}).status_code == 403
478
+ assert client.post(path, headers={"sec-fetch-site": "same-origin"}).status_code == 303
479
+
480
+
481
+ def test_a_unicode_site_matches_the_punycode_origin_browsers_send(ctx, config, discovery_for_ctx):
482
+ """Browsers serialise an IDN host as punycode in Origin; the configured site must match it."""
483
+ site = AuthContext(
484
+ replace(config, redirect_uri="https://b\u00fccher.example/api/auth/callback"),
485
+ keys=ctx.keys,
486
+ discovery=discovery_for_ctx,
487
+ )
488
+ app = FastAPI()
489
+ app.include_router(create_auth_router(site))
490
+ client = TestClient(app, follow_redirects=False)
491
+
492
+ response = client.post(
493
+ f"{config.route_prefix}/logout", headers={"origin": "https://xn--bcher-kva.example"}
494
+ )
495
+ assert response.status_code == 303
496
+
497
+
368
498
  def test_logout_clears_the_cookie_and_hands_off_to_the_broker(client, config):
369
499
  client.cookies.set(config.cookie_name, session_cookie_value(config))
370
- response = client.get(f"{config.route_prefix}/logout")
500
+ response = client.post(f"{config.route_prefix}/logout", headers=SAME_ORIGIN)
371
501
 
372
- assert response.status_code == 307
502
+ assert response.status_code == 303
373
503
  assert response.headers["location"].startswith(VECTORS["discovery"]["end_session_endpoint"])
374
504
  cleared = response.headers.get("set-cookie", "")
375
505
  assert config.cookie_name in cleared
@@ -393,7 +523,7 @@ def test_logout_without_a_broker_endpoint_stays_local(config, signer, token_hand
393
523
  app.include_router(create_auth_router(ctx))
394
524
  client = TestClient(app, follow_redirects=False)
395
525
 
396
- response = client.get(f"{config.route_prefix}/logout")
526
+ response = client.post(f"{config.route_prefix}/logout", headers=SAME_ORIGIN)
397
527
  assert response.headers["location"] == "/"
398
528
 
399
529
 
@@ -468,9 +598,16 @@ def test_logout_next_is_honoured_when_there_is_no_broker_endpoint(config, signer
468
598
  app.include_router(create_auth_router(ctx))
469
599
  client = TestClient(app, follow_redirects=False)
470
600
 
471
- assert client.get(f"{config.route_prefix}/logout?next=/bye").headers["location"] == "/bye"
601
+ assert (
602
+ client.post(f"{config.route_prefix}/logout?next=/bye", headers=SAME_ORIGIN).headers[
603
+ "location"
604
+ ]
605
+ == "/bye"
606
+ )
472
607
  # A hostile next is still dropped.
473
- hostile = client.get(f"{config.route_prefix}/logout?next=https://evil.test/")
608
+ hostile = client.post(
609
+ f"{config.route_prefix}/logout?next=https://evil.test/", headers=SAME_ORIGIN
610
+ )
474
611
  assert hostile.headers["location"] == "/"
475
612
 
476
613
 
@@ -599,7 +736,7 @@ def test_logout_names_the_session_so_the_broker_does_not_have_to_ask(ctx, config
599
736
 
600
737
  _establish_session(client, config)
601
738
 
602
- response = client.get(f"{config.route_prefix}/logout")
739
+ response = client.post(f"{config.route_prefix}/logout", headers=SAME_ORIGIN)
603
740
 
604
741
  params = parse_qs(urlparse(response.headers["location"]).query)
605
742
  assert "id_token_hint" in params, "the broker was told whose session to end"
@@ -610,7 +747,7 @@ def test_logout_discards_the_hint_it_just_spent(client, config):
610
747
  """One shot. A hint left behind would name a session that no longer exists."""
611
748
  _establish_session(client, config)
612
749
 
613
- response = client.get(f"{config.route_prefix}/logout")
750
+ response = client.post(f"{config.route_prefix}/logout", headers=SAME_ORIGIN)
614
751
 
615
752
  cleared = [
616
753
  header
@@ -627,8 +764,8 @@ def test_logout_without_a_stored_hint_still_reaches_the_broker(client, config):
627
764
  """
628
765
  client.cookies.set(config.cookie_name, session_cookie_value(config))
629
766
 
630
- response = client.get(f"{config.route_prefix}/logout")
767
+ response = client.post(f"{config.route_prefix}/logout", headers=SAME_ORIGIN)
631
768
 
632
- assert response.status_code == 307
769
+ assert response.status_code == 303
633
770
  assert response.headers["location"].startswith(VECTORS["discovery"]["end_session_endpoint"])
634
771
  assert "id_token_hint" not in parse_qs(urlparse(response.headers["location"]).query)
@@ -0,0 +1,32 @@
1
+ """Tokens built to crash the parser rather than to forge a principal.
2
+
3
+ The header is read before any signature check, so whatever it contains comes from an
4
+ unauthenticated caller. Every such token must end as a typed InvalidTokenError (a 401),
5
+ never as an exception the framework turns into a 500.
6
+ """
7
+
8
+ import base64
9
+
10
+ import pytest
11
+
12
+ from eager_auth import InvalidTokenError, verify_id_token
13
+
14
+
15
+ def _b64(raw: bytes) -> str:
16
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
17
+
18
+
19
+ def _token_with_header(header: bytes) -> str:
20
+ return f"{_b64(header)}.{_b64(b'{}')}.{_b64(b'signature')}"
21
+
22
+
23
+ @pytest.mark.parametrize("depth", [10_000, 100_000])
24
+ def test_deeply_nested_header_is_malformed_not_a_crash(depth, config, resolver):
25
+ # GHSA-8wjv-2p76-3863: PyJWT 2.13.0 lets json.loads raise RecursionError here, which
26
+ # is a RuntimeError, not an InvalidTokenError. Which typed error it becomes depends on the
27
+ # interpreter: up to 3.13 json.loads recurses and the header is malformed; 3.14 parses it
28
+ # iteratively and the token fails at the signature instead. Either is a 401, never a 500.
29
+ header = b'{"alg":"RS256","x":' + b"[" * depth + b"]" * depth + b"}"
30
+
31
+ with pytest.raises(InvalidTokenError):
32
+ verify_id_token(_token_with_header(header), config=config, keys=resolver)
File without changes
File without changes
File without changes
File without changes