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.
- {eager_auth-2.2.0 → eager_auth-2.2.2}/PKG-INFO +2 -2
- {eager_auth-2.2.0 → eager_auth-2.2.2}/pyproject.toml +4 -2
- {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/fastapi.py +91 -6
- {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/pending.py +23 -5
- {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/tokens.py +3 -1
- {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_fastapi.py +146 -9
- eager_auth-2.2.2/tests/test_hostile_tokens.py +32 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/.gitignore +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/LICENSE +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/README.md +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/__init__.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/claims.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/config.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/context.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/discovery.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/errors.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/flow.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/keys.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/presets.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/principal.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/src/eager_auth/session.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/conftest.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_callback.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_config.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_conformance.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_flow.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_http_jwks_source.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_keys.py +0 -0
- {eager_auth-2.2.0 → eager_auth-2.2.2}/tests/test_lifecycle.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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"{
|
|
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
|
|
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", "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
36
|
-
a
|
|
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
|
|
51
|
+
if not candidate or "\\" in candidate:
|
|
52
|
+
return None
|
|
53
|
+
if not _PLAIN_PATH.fullmatch(candidate):
|
|
39
54
|
return None
|
|
40
|
-
|
|
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
|
-
|
|
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.
|
|
500
|
+
response = client.post(f"{config.route_prefix}/logout", headers=SAME_ORIGIN)
|
|
371
501
|
|
|
372
|
-
assert response.status_code ==
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
767
|
+
response = client.post(f"{config.route_prefix}/logout", headers=SAME_ORIGIN)
|
|
631
768
|
|
|
632
|
-
assert response.status_code ==
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|